new-project.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. // @ts-nocheck
  2. import { useParams } from 'common'
  3. import { ChangeEvent, useEffect, useRef, useState } from 'react'
  4. import { AWS_REGIONS } from 'shared-data'
  5. import { toast } from 'sonner'
  6. import {
  7. Button,
  8. Checkbox,
  9. Input,
  10. Select,
  11. SelectContent,
  12. SelectItem,
  13. SelectTrigger,
  14. SelectValue,
  15. } from 'ui'
  16. import { Admonition } from 'ui-patterns/admonition'
  17. import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
  18. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  19. import { isVercelUrl } from '@/components/interfaces/Integrations/Vercel/VercelIntegration.utils'
  20. import { Markdown } from '@/components/interfaces/Markdown'
  21. import VercelIntegrationWindowLayout from '@/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout'
  22. import { ScaffoldColumn, ScaffoldContainer } from '@/components/layouts/Scaffold'
  23. import { PasswordStrengthBar } from '@/components/ui/PasswordStrengthBar'
  24. import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
  25. import { useIntegrationsQuery } from '@/data/integrations/integrations-query'
  26. import { useIntegrationVercelConnectionsCreateMutation } from '@/data/integrations/integrations-vercel-connections-create-mutation'
  27. import { useVercelProjectsQuery } from '@/data/integrations/integrations-vercel-projects-query'
  28. import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
  29. import { useProjectCreateMutation } from '@/data/projects/project-create-mutation'
  30. import {
  31. useDataApiRevokeOnCreateDefaultEnabled,
  32. useTrackDefaultPrivilegesExposure,
  33. } from '@/hooks/misc/useDataApiRevokeOnCreateDefault'
  34. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  35. import { usePHFlag } from '@/hooks/ui/useFlag'
  36. import { BASE_PATH, PROVIDERS } from '@/lib/constants'
  37. import { getInitialMigrationSQLFromGitHubRepo } from '@/lib/integration-utils'
  38. import { passwordStrength, PasswordStrengthScore } from '@/lib/password-strength'
  39. import { generateStrongPassword } from '@/lib/project'
  40. import { useTrack } from '@/lib/telemetry/track'
  41. import { useIntegrationInstallationSnapshot } from '@/state/integration-installation'
  42. import type { NextPageWithLayout } from '@/types'
  43. const VercelIntegration: NextPageWithLayout = () => {
  44. return (
  45. <>
  46. <ScaffoldContainer className="flex flex-col gap-6 grow py-8">
  47. <ScaffoldColumn className="mx-auto w-full max-w-md">
  48. <header>
  49. <h2>New project</h2>
  50. <Markdown
  51. className="text-foreground-light"
  52. content={`Choose the Briven organization you wish to install in`}
  53. />
  54. </header>
  55. <CreateProject />
  56. <Admonition
  57. type="default"
  58. layout="horizontal"
  59. title="You can uninstall this Integration at any time."
  60. description="You can remove this integration at any time via Vercel or the Briven dashboard"
  61. />
  62. </ScaffoldColumn>
  63. </ScaffoldContainer>
  64. </>
  65. )
  66. }
  67. VercelIntegration.getLayout = (page) => (
  68. <VercelIntegrationWindowLayout>{page}</VercelIntegrationWindowLayout>
  69. )
  70. const CreateProject = () => {
  71. const { data: selectedOrganization } = useSelectedOrganizationQuery()
  72. const [projectName, setProjectName] = useState('')
  73. const [dbPass, setDbPass] = useState('')
  74. const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('')
  75. const [passwordStrengthScore, setPasswordStrengthScore] = useState(-1)
  76. const [shouldRunMigrations, setShouldRunMigrations] = useState(true)
  77. const [dbRegion, setDbRegion] = useState<string>(PROVIDERS.AWS.default_region.displayName)
  78. const track = useTrack()
  79. const snapshot = useIntegrationInstallationSnapshot()
  80. const isDataApiRevokeOnCreateDefault = useDataApiRevokeOnCreateDefaultEnabled()
  81. const dataApiRevokeOnCreateDefaultFlag = usePHFlag<boolean>('dataApiRevokeOnCreateDefault')
  82. const [dataApiDefaultPrivileges, setDataApiDefaultPrivileges] = useState(
  83. !isDataApiRevokeOnCreateDefault
  84. )
  85. const hasUserModifiedDataApiDefaultPrivileges = useRef(false)
  86. useEffect(() => {
  87. if (dataApiRevokeOnCreateDefaultFlag === undefined) return
  88. if (hasUserModifiedDataApiDefaultPrivileges.current) return
  89. setDataApiDefaultPrivileges(!dataApiRevokeOnCreateDefaultFlag)
  90. }, [dataApiRevokeOnCreateDefaultFlag])
  91. const { slug, next, currentProjectId: foreignProjectId, externalId } = useParams()
  92. useTrackDefaultPrivilegesExposure({
  93. surface: 'vercel',
  94. orgSlug: slug,
  95. dataApiDefaultPrivileges,
  96. hasUserModified: hasUserModifiedDataApiDefaultPrivileges.current,
  97. })
  98. async function checkPasswordStrength(value: string) {
  99. const { message, strength } = await passwordStrength(value)
  100. setPasswordStrengthScore(strength)
  101. setPasswordStrengthMessage(message)
  102. }
  103. const { mutateAsync: createConnections } = useIntegrationVercelConnectionsCreateMutation()
  104. const { data: organizationData } = useOrganizationsQuery()
  105. const organization = organizationData?.find((x) => x.slug === slug)
  106. /**
  107. * array of integrations installed
  108. */
  109. const { data: integrationData } = useIntegrationsQuery()
  110. /**
  111. * the vercel integration installed for organization chosen
  112. */
  113. const organizationIntegration = integrationData?.find((x) => x.organization.slug === slug)
  114. /**
  115. * Vercel projects available for this integration
  116. */
  117. const { data: vercelProjects } = useVercelProjectsQuery(
  118. {
  119. organization_integration_id: organizationIntegration?.id,
  120. },
  121. { enabled: organizationIntegration !== undefined }
  122. )
  123. function onProjectNameChange(e: ChangeEvent<HTMLInputElement>) {
  124. e.target.value = e.target.value.replace(/\./g, '')
  125. setProjectName(e.target.value)
  126. }
  127. function onDbPassChange(e: ChangeEvent<HTMLInputElement>) {
  128. const value = e.target.value
  129. setDbPass(value)
  130. if (value == '') {
  131. setPasswordStrengthScore(-1)
  132. setPasswordStrengthMessage('')
  133. } else checkPasswordStrength(value)
  134. }
  135. function generatePassword() {
  136. const password = generateStrongPassword()
  137. setDbPass(password)
  138. checkPasswordStrength(password)
  139. }
  140. const [newProjectRef, setNewProjectRef] = useState<string | undefined>(undefined)
  141. const { mutate: createProject } = useProjectCreateMutation({
  142. onSuccess: (res) => {
  143. setNewProjectRef(res.ref)
  144. track(
  145. 'project_creation_simple_version_submitted',
  146. {
  147. surface: 'vercel',
  148. dataApiEnabled: true,
  149. dataApiDefaultPrivilegesGranted: dataApiDefaultPrivileges,
  150. ...(dataApiRevokeOnCreateDefaultFlag !== undefined && {
  151. dataApiRevokeOnCreateDefaultEnabled: dataApiRevokeOnCreateDefaultFlag,
  152. }),
  153. },
  154. {
  155. project: res.ref,
  156. organization: res.organization_slug,
  157. }
  158. )
  159. },
  160. onError: (error) => {
  161. toast.error(error.message)
  162. snapshot.setLoading(false)
  163. },
  164. })
  165. async function onCreateProject() {
  166. if (!organizationIntegration) return console.error('No organization installation details found')
  167. if (!organizationIntegration?.id) return console.error('No organization installation ID found')
  168. if (!foreignProjectId) return console.error('No foreignProjectId set')
  169. if (!organization) return console.error('No organization set')
  170. snapshot.setLoading(true)
  171. let dbSql: string | undefined
  172. if (shouldRunMigrations) {
  173. const id = toast(`Fetching initial migrations from GitHub repo`)
  174. const migrationSql = await getInitialMigrationSQLFromGitHubRepo(externalId)
  175. if (migrationSql) dbSql = migrationSql
  176. toast.success(`Done fetching initial migrations`, { id })
  177. }
  178. createProject({
  179. organizationSlug: organization.slug,
  180. name: projectName,
  181. dbPass,
  182. dbRegion,
  183. dbSql,
  184. dataApiRevokeDefaultPrivileges: !dataApiDefaultPrivileges,
  185. })
  186. }
  187. // Wait for the new project to be created before creating the connection
  188. const { data, isSuccess } = useProjectSettingsV2Query(
  189. { projectRef: newProjectRef },
  190. {
  191. enabled: newProjectRef !== undefined,
  192. // refetch until the project is created
  193. refetchInterval: (query) => {
  194. const data = query.state.data
  195. return ((data?.service_api_keys ?? []).length ?? 0) > 0 ? false : 1000
  196. },
  197. }
  198. )
  199. useEffect(() => {
  200. if (!isSuccess) return
  201. const onSuccessFunc = async () => {
  202. const isReady = (data.service_api_keys ?? []).length > 0
  203. if (!isReady || !organizationIntegration || !foreignProjectId || !newProjectRef) {
  204. return
  205. }
  206. const projectDetails = vercelProjects?.find((x: any) => x.id === foreignProjectId)
  207. try {
  208. await createConnections({
  209. organizationIntegrationId: organizationIntegration?.id,
  210. connection: {
  211. foreign_project_id: foreignProjectId,
  212. briven_project_ref: newProjectRef,
  213. integration_id: '0',
  214. metadata: {
  215. ...projectDetails,
  216. brivenConfig: {
  217. projectEnvVars: {
  218. write: true,
  219. },
  220. },
  221. },
  222. },
  223. orgSlug: selectedOrganization?.slug,
  224. })
  225. } catch (error) {
  226. console.error('An error occurred during createConnections:', error)
  227. return
  228. }
  229. snapshot.setLoading(false)
  230. if (next && isVercelUrl(next)) {
  231. window.location.href = next
  232. }
  233. }
  234. onSuccessFunc()
  235. }, [data, isSuccess])
  236. return (
  237. <div>
  238. <p className="mb-2">Briven project details</p>
  239. <div className="py-2">
  240. <FormItemLayout
  241. id="projectName"
  242. isReactForm={false}
  243. layout="vertical"
  244. label="Project name"
  245. size="tiny"
  246. >
  247. <Input
  248. autoFocus
  249. id="projectName"
  250. type="text"
  251. placeholder=""
  252. value={projectName}
  253. onChange={onProjectNameChange}
  254. />
  255. </FormItemLayout>
  256. </div>
  257. <div className="py-2">
  258. <FormItemLayout
  259. id="dbPass"
  260. isReactForm={false}
  261. layout="vertical"
  262. label="Database password"
  263. size="tiny"
  264. description={
  265. <PasswordStrengthBar
  266. passwordStrengthScore={passwordStrengthScore as PasswordStrengthScore}
  267. password={dbPass}
  268. passwordStrengthMessage={passwordStrengthMessage}
  269. generateStrongPassword={generatePassword}
  270. />
  271. }
  272. >
  273. <PasswordInput
  274. id="dbPass"
  275. type="password"
  276. placeholder="Type in a strong password"
  277. value={dbPass}
  278. reveal
  279. copy={dbPass.length > 0}
  280. onChange={onDbPassChange}
  281. />
  282. </FormItemLayout>
  283. </div>
  284. <div className="py-2">
  285. <div className="mt-1">
  286. <FormItemLayout
  287. id="region"
  288. isReactForm={false}
  289. layout="vertical"
  290. label="Region"
  291. description="Select a region close to your users for the best performance."
  292. className="gap-[2px]"
  293. size="tiny"
  294. >
  295. <Select value={dbRegion} onValueChange={(region) => setDbRegion(region)}>
  296. <SelectTrigger id="region">
  297. <SelectValue />
  298. </SelectTrigger>
  299. <SelectContent>
  300. {Object.keys(AWS_REGIONS).map((option: string, i) => {
  301. const label = Object.values(AWS_REGIONS)[i].displayName
  302. return (
  303. <SelectItem key={option} value={label}>
  304. <div className="flex gap-2">
  305. <img
  306. alt="region icon"
  307. className="w-5 rounded-xs"
  308. src={`${BASE_PATH}/img/regions/${Object.values(AWS_REGIONS)[i].code}.svg`}
  309. />
  310. <span>{label}</span>
  311. </div>
  312. </SelectItem>
  313. )
  314. })}
  315. </SelectContent>
  316. </Select>
  317. </FormItemLayout>
  318. </div>
  319. </div>
  320. <div className="py-2 pb-4">
  321. <div className="items-top flex space-x-2">
  322. <Checkbox
  323. id="shouldRunMigrations"
  324. name="shouldRunMigrations"
  325. checked={shouldRunMigrations}
  326. onCheckedChange={(checked) => setShouldRunMigrations(!!checked)}
  327. />
  328. <div className="grid gap-1.5 leading-none">
  329. <label
  330. htmlFor="enable-realtime"
  331. className="text-sm text-foreground-light flex items-center space-x-2 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
  332. >
  333. Create sample tables with seed data
  334. </label>
  335. <p className="text-sm text-foreground-muted">
  336. To get you started quickly, we can create new tables for you with seed (sample) data.
  337. You can delete these tables later.
  338. </p>
  339. </div>
  340. </div>
  341. </div>
  342. <div className="py-2 pb-4">
  343. <div className="items-top flex space-x-2">
  344. <Checkbox
  345. id="dataApiDefaultPrivileges"
  346. name="dataApiDefaultPrivileges"
  347. checked={dataApiDefaultPrivileges}
  348. onCheckedChange={(checked) => {
  349. hasUserModifiedDataApiDefaultPrivileges.current = true
  350. setDataApiDefaultPrivileges(!!checked)
  351. }}
  352. />
  353. <div className="grid gap-1.5 leading-none">
  354. <label
  355. htmlFor="dataApiDefaultPrivileges"
  356. className="text-sm text-foreground-light flex items-center space-x-2 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
  357. >
  358. Automatically expose new tables
  359. </label>
  360. <p className="text-sm text-foreground-muted">
  361. Grants privileges to Data API roles by default, exposing new tables. We recommend
  362. disabling this to control access manually.
  363. </p>
  364. </div>
  365. </div>
  366. </div>
  367. <div className="flex flex-row w-full justify-end">
  368. <Button
  369. size="medium"
  370. className="self-end"
  371. disabled={snapshot.loading}
  372. loading={snapshot.loading}
  373. onClick={onCreateProject}
  374. >
  375. Create Project
  376. </Button>
  377. </div>
  378. </div>
  379. )
  380. }
  381. export default VercelIntegration